"""
pyInnovator, part of pyom, the Python Bindings for Aras Innovator

pyInnovator: A Python wrapper for the Aras IOM DLL

By: Jason Morgan
pypi * cropwell dot net
(replace * with the at symbol, dot with a '.' and remove spaces)

License:  Apache 2.0

To install the DLLs yu must run the following:
c:\Windows\Microsoft.NET\Framework\v4.0.30319\regasm C:\Users\JMorgan\Documents\Python\pyInnovator\IOM_10_0.dll /verbose /codebase /tlb
c:\Windows\Microsoft.NET\Framework\v4.0.30319\regasm C:\Users\JMorgan\Documents\Python\pyInnovator\IOM_9_4.dll /verbose /codebase /tlb
"""

#NOTE:  As far as I can tell Aras have never released a 64bit IOM.dll, so this will only work in 32 bit Python

#Using pythonnet,  I found via JetBrains (dot)peek that the Assembly is called Aras.IOM even though the file is called IOM.
#Renaming the file perhaps helped.

#clr is aka pythonnet
import clr
import os
import sys

import logging
import warnings

class pyInnovatorError(Exception):
  pass

class InvalidPartId(pyInnovatorError):
  pass

class InvalidId(pyInnovatorError):
  pass

class QueryError(pyInnovatorError):
  pass

class IOMError(pyInnovatorError):
  pass

class UpdateError(pyInnovatorError):
  pass

class arasID(str):
  def __new__(cls, s="", keyed_name = None):
    obj = str.__new__(cls, s)
    obj.keyed_name = keyed_name
    return obj

  def __init__(self, s="", *args, **kw):
    if len(s)!=32:
      raise ValueError, "Aras IDs are always 32 characters"
    try:
      self.value = int(s,16)
    except ValueError:
      raise ValueError, "%s does not look like an Aras ID" % s

  def __str__(self):
    return self.keyed_name if self.keyed_name is not None else self.ID()

  def ID(self):
    return ("%X" % self.value).zfill(32)

def findDLL(DLL):
  for path in sys.path:
    fullpath = os.path.abspath(os.path.join(path,DLL))
    if os.path.exists(fullpath):
      break
    fullpath = None
  return fullpath
  
def conv(x):
  return x if isinstance(x,(str,unicode)) else str(x)

class Innovator(object):
  def __init__(self, URL=None, DB=None, User=None, Password=None, debug=False, version="9.4"):
    self.version = version
    
    if version == "9.4":
      dllpath = findDLL("IOM_9_4.dll")
      if not dllpath:
        raise IOMError, "IOM_9_4.DLL File Missing"
    elif version == "10.0":
      dllpath = findDLL("IOM_10_0.dll")
      if not dllpath:
        raise IOMError, "IOM_10_0.DLL File Missing"
    else:
      raise IOMError, "Unsupported IOM version"

    clr.System.Reflection.Assembly.LoadFile(dllpath)
    import Aras.IOM
    
    if URL is None:
      URL = "http://localhost/InnovatorServer"
    if DB is None:
      DB = "InnovatorSolutions"
    if User is None:
      User = "Admin"
    if Password is None:
      Password = "innovator"

    self.User = User
      
    self.log = logging.getLogger("Innovator-%s" % id(self))
    con = logging.StreamHandler()
    fmt = logging.Formatter('%(module)s-%(levelname)s: %(message)s')
    con.setFormatter(fmt)
    self.log.addHandler(con)
    self.log.setLevel(logging.ERROR)
    self.log.propagate = False
    
    if debug:
      self.log.setLevel(logging.DEBUG)
      
    self.con = Aras.IOM.IomFactory.CreateHttpServerConnection(URL, DB, User, Password)
    
    res = self.con.Login()
    
    self.log.debug(res)

    self.inn = Aras.IOM.Innovator(self.con)
    
  def getItemTypeInstances(self,ItemType, properties=None, where=None, compare=None, sql_where=None):
    MyItem = self.inn.newItem(ItemType,"get")
    if compare is None:
      compare="="
    if isinstance(properties,list):
      MyItem.setAttribute("select", ",".join(x for x in properties))
    if sql_where is None and isinstance(where,dict):
      wherestr = " and ".join("([{0}].{1} {3} '{2}')".format(ItemType.replace(" ","_"),k,v,compare) for k,v in where.iteritems())
      MyItem.setAttribute("where", wherestr)
      self.log.debug(wherestr)
    elif isinstance(sql_where,str):
      self.log.debug(sql_where)
      MyItem.setAttribute("where", sql_where)
      
    MyResult = MyItem.apply()
    
    if MyResult.isError():
      self.log.warning("\n\nQuery Error: " + MyResult.getErrorDetail())
      raise QueryError
    
    return MyResult.getItemsByXPath("//Item[@type='{0}']".format((ItemType)))

  def getAllParts(self):
    AParts = []

    items = self.getItemTypeInstances("Part")

    for i in xrange(items.getItemCount()):
      AParts.append(items.getItemByIndex(i).getProperty("keyed_name"))
    return AParts

  def _getItemByKeyedName(self, itemType, key):
    Item = self.inn.getItemByKeyedName(itemType, key)
    if Item is None:
      raise InvalidId,"%s with key %s does not exist" % (itemType, key)
    if Item.isError():
      raise pyInnovatorError, Item.getErrorDetail()
    return Item

  def getUserList(self):
    """Return a list of <Formame> <Surmame>"""
    MyItem = self.getItemTypeInstances("User", properties=["first_name","last_name"])
    
    print "\n\n\nQuery Found " + str(MyItem.getItemCount()) + " User Items\n"

    userlist = []
    for i in xrange(MyItem.getItemCount()):
      fn = MyItem.getItemByIndex(i).getProperty("first_name","na")
      sn = MyItem.getItemByIndex(i).getProperty("last_name","na")
      userlist.append(fn + " " + sn)
    return userlist

  def addItem(self, itemType, keyedName = None, defaults = None, attributes = None, **properties):
    #Note sure if I can use keyed_name to add items
    item = self.inn.newItem(itemType, "add")

    if keyedName:
      item.setProperty("item_number", conv(keyedName) )

    self.log.debug("Added %s '%s'" % (itemType, keyedName))

    if isinstance(defaults, dict):
      for k,v in defaults.iteritems():
        properties.setdefault(k,v)

    self.updateProperties(item, attributes, **properties)

  def addPart(self, partPN, attributes = None, **properties):
    partItem = self.inn.newItem("Part", "add")
    partItem.setProperty("item_number",conv(partPN))
    self.log.debug("Added %s" % partPN)

    #Set a default description
    properties.setdefault("description",None)
    #Set a default name
    properties.setdefault("name", properties["description"])
    #If the name is nothing, set it to the partPN
    if properties["name"] in ("",None):
      properties["name"] = partPN
    
    self.updateProperties(partItem, attributes, **properties)
    
  def updateItem(self, itemType, keyOrID, defaults = None, **properties):
    if isinstance(keyOrID,arasID):
      Item = self.inn.getItemById(itemType, keyOrID)
    else:
      Item = self._getItemByKeyedName(itemType, keyOrID)

    if defaults is None:
      defaults = {}

    for k,v in defaults.iteritems():
      properties.setdefault(k,v)

    Item.lockItem()
    Item.setAction("update")

    result, updated = self.updateProperties(Item, **properties)

    if result.isError():
      raise UpdateError, "Update Error: %s (%s)" % (keyOrID, itemType)
    else:
      self.log.debug("Updated %s (%s)" % (keyOrID, itemType))

    return result, updated

  def updatePart(self, keyOrID, **properties):
    """For a given Part (key or ID), update it's properties (dict)"""
    try:
      result = self.updateItem("Part", keyOrID, **properties)
    except pyInnovatorError as er:
      print "** Part %s not updated, reason: %s" % (keyOrID, er)
      return None, None
    return result

  def updateProperties(self, Item, attributes = None, **properties):
    """Update the properties (dict) for a given Item (IOM Item)
    Returns the updated item and a list of properties actually changed"""
    updated = set()
    for key, value in properties.iteritems():
      if value is not None and Item.getProperty(key) != conv(value):
        Item.setProperty(key,conv(value))
        updated.add(key)

    if isinstance(attributes, dict):
      for key, value in attributes.iteritems():
        Item.setAttribute(key,conv(value))

    result = Item.apply()

    if result.isError():
      raise pyInnovatorError, result.getErrorDetail()

    self.log.debug("Wrote %s to %s" % (properties, Item.getProperty('keyed_name')))

    result.unlockItem()
    return result,list(updated)

  def getManufacturer(self, keyOrID):
    """Return a given manufacturer (key or ID)"""
    if isinstance(keyOrID, arasID):
      Item = self.inn.getItemById("Manufacturer", keyOrID)
    else:
      Item = self._getItemByKeyedName("Manufacturer", keyOrID)
    return Item
  
  def getManufacturerParts(self, manufacturerKeyOrID, partKeyOrID):
    """Return a single item or collection of manufacturer parts"""
    if isinstance(partKeyOrID, arasID):
      Items = self.inn.getItemById("Manufacturer Part", partKeyOrID)
    else:
      manuf = self.getManufacturer(manufacturerKeyOrID)
      where = {'keyed_name': partKeyOrID,
               'manufacturer': manuf.getID()}
      Items = self.getItemTypeInstances("Manufacturer Part", where=where)
    return Items
  
  def addManufacturer(self, manufacturerName):
    """Create a new manufacturer record"""
    try:
      manufItem = self.getManufacturer(manufacturerName)
    except (InvalidId, QueryError):
      manufItem = self.inn.newItem("Manufacturer", "add")
      manufItem.setProperty("name",conv(manufacturerName))
      result = manufItem.apply()
  
      if result.isError():
        self.log.error(result.getErrorDetail())
      else:
        self.log.debug("Added %s" % manufacturerName)
    else:
      self.log.warning("Manufacturer already exists")
      
  def addManufacturerPart(self, manufacturerKeyOrID, partPN, **properties):
    """Create a manufacturer part (str) and attach it to a manufacturer (key or ID)"""
    manufItem = self.getManufacturer(manufacturerKeyOrID)

    try:
      manufPart = self.getManufacturerParts(manufacturerKeyOrID, partPN)
    except (InvalidId, QueryError):
      manufPart = self.inn.newItem("Manufacturer Part", "add")
      manufPart.setProperty("item_number",conv(partPN))
      manufPart.setProperty("manufacturer",manufItem.getID())
      self.updateProperties(manufPart, **properties)
    else:
      self.log.info("Part %s already exists" % partPN)


  def getUserVault(self,UserID):
    MyItem = self.getItemTypeInstances("User", properties=["default_vault"],where={"login_name" : UserID})

    if MyItem.getItemCount() == 0:
      raise pyInnovatorError, "User not found"

    return MyItem.getItemByIndex(0).getProperty("default_vault")

  def resolveFileType(self, ext):
    #Try an exact match
    MyItem = self.getItemTypeInstances("FileType", sql_where="lower(FileType.extension) = '%s'" % ext.lower())

    if MyItem.getItemCount() == 0:
      MyItem = self.getItemTypeInstances("FileType", sql_where="lower(extension) like '%%%s%%'" % ext.lower())
      if MyItem.getItemCount() == 0:
        raise pyInnovatorError, "Unregistered file extension"

    return MyItem.getItemByIndex(0)

  def addFile(self, filePath, fileName=None, comments=""):
    """Add a file from a given path, we may lie about the name"""
    #vault = self.getUserVault(self.User)

    #If filename is specified, use that to determine filename and type instead of the path
    #If filename is not specified, use the file path

    if fileName is None:
      fileName = filePath
    else:
      fileName = os.path.split(fileName)[1]

    fp, ffn  = os.path.split(os.path.abspath(fileName))
    fn, fe = os.path.splitext(ffn)

    if not os.path.exists(filePath):
      raise pyInnovatorError, "File not found"

    #size = os.path.getsize(filePath)
    fileType = self.resolveFileType(fe[1:])

    fileItem = self.inn.newItem("File", "add")
    fileItem.setProperty("filename",ffn)
    #fileItem.setProperty("actual_filename", conv(fp))
    #fileItem.setProperty("checkedout_path", conv(fp))
    fileItem.setProperty("comments", conv(comments))
    #fileItem.setProperty("file_size", str(size))
    fileItem.setProperty("file_type", fileType.getID())

    fileItem.attachPhysicalFile(filePath)#, vault)

    result = fileItem.apply()

    if result.isError():
      raise pyInnovatorError, result.getErrorDetail()
    else:
      self.log.debug("Added file %s" % ffn)

    return result

  def addDocument(self, name=None, filepaths=None, **properties):
    docItem = self.inn.newItem("Document", "add")

    #docItem.setProperty("item_number",conv(name))

    docItem.setProperty("name", conv(name))

    comments = properties.pop("comments", "")

    docItem, updated = self.updateProperties(docItem, **properties)

    if filepaths is not None and not docItem.isError():
      return self.addFilesToDocument(arasID(docItem.getID()), filepaths, comments = comments)

  def addFilesToDocument(self, KeyOrID, filemap, comments):
    Item = self.getItem("Document", KeyOrID)

    if isinstance(filemap,list):
      filemap = {f:f for f in filemap}
    elif isinstance(filemap, (unicode,str)):
      filemap = {filemap:filemap}

    if not isinstance(filemap,dict):
      raise pyInnovatorError, "Invalid type for filemap"

    count = 0
    for fn,fp in filemap.iteritems():
      fileItem = self.addFile(fp,fn,comments)
      fileID = arasID(fileItem.getID())

      if not Item or not  fileItem:
        raise pyInnovatorError, "Error adding file to document"
      else:
        self.addRelationship(KeyOrID, fileID, "Document", "File", "Document File")
        count += 1
    return count

  def getItem(self, itemType, keyOrID):
    """Return a given IOM Item, search by key or ID"""
    if isinstance(keyOrID, arasID):
      Item = self.inn.getItemById(itemType, keyOrID.ID())
    else:
      Item = self._getItemByKeyedName(itemType, keyOrID)

    if Item and Item.isError():
      raise pyInnovatorError, Item.getErrorDetail()

    return Item

  def getPart(self, keyOrID):
    """Return a given part Item, search by key or ID"""
    return self.getItem("Part", keyOrID)

  def getItems(self, itemType):
    """Return a collection (dict of {id:key}) of all instances of a given itemType (str)"""
    Items = self.getItemTypeInstances(itemType, properties=["keyed_name"])

    result = {}

    self.log.debug("Query Found " + str(Items.getItemCount()) + " %s Items" % itemType)
    for i in xrange(Items.getItemCount()):
      Item = Items.getItemByIndex(i)
      k = Item.getProperty("keyed_name")
      result[arasID(Item.getID(), k)] = k
    return result

  def addRelationship(self, parentKeyOrID, childKeyOrID, parentItemType, childItemType, relationshipName, **properties):
    """Add a relationship child item to a given parent (both key or ID)
    You need to ensure the partent/child Itemtypes and relationsip name are correct"""
    parentItem = self.getItem(parentItemType, parentKeyOrID)
    childItem = self.getItem(childItemType, childKeyOrID)

    relItem = self.inn.newItem(relationshipName,"add")

    for key,value in properties.iteritems():
      if value is not None:
        relItem.setProperty(key,conv(value))

    relItem.setRelatedItem(childItem)
    parentItem.addRelationship(relItem)

    result = parentItem.apply()

    if result.isError():
      raise pyInnovatorError, result.getErrorDetail()

    return result
  
  def addBOMItem(self, partKeyOrID, bomItemKeyOrID, **properties):
    """Add a BOM line to a given part"""
    properties.setdefault('reference_designator',None)
    properties.setdefault('quantity',0)

    self.addRelationship(partKeyOrID, bomItemKeyOrID, "Part", "Part", "Part BOM", **properties)

    self.log.debug("Added part %s to BOM of %s" % (partKeyOrID, bomItemKeyOrID))
      
  def updateBOMItem(self, partKeyOrID, bomItemKeyOrID, **properties):
    return self.updateRelationship(partKeyOrID, bomItemKeyOrID, "Part", "Part", "Part BOM", **properties)

  def getRelated(self, parentItemKeyOrID, parentItemType, relationshipName, childItemType):
    """For a given parentItemID (keyed_name or ID), parentItemType, relationshipName, relatedItemType (all str)
    return a list of relationship items (IOM item) and childItems (IOM item) and childID (item ID string)"""

    parentItem = self.getItem(parentItemType, parentItemKeyOrID)
    rel = parentItem.fetchRelationships(relationshipName)
    relitems = rel.getRelationships()
    self.log.debug("%s has %d entries" % (parentItemKeyOrID, relitems.getItemCount()))

    items = []
    for i in xrange(relitems.getItemCount()):
      relationship = relitems.getItemByIndex(i)
      childItem = self.inn.getItemById(childItemType, relationship.getProperty('related_id'))
      items.append({"relationship": relationship, "childItem": childItem, "childID": childItem.getID()})

    return items
      
  def getBOM(self, partKeyOrID):
    found = {}
    bom = self.getRelated(partKeyOrID, "Part", "Part BOM", "Part")

    for item in bom:
      childItem = item['childItem']
      childItemKey = childItem.getProperty('keyed_name')
      childItemID = childItem.getID()
      found[arasID(childItemID, childItemKey)] = {}
      types = {'quantity':int, 'creo_level':int, 'creo_ext':str, 'creo_ver':str, 'reference_designator':str}
      for k, conv in types.iteritems():
        b = item['relationship'].getProperty(k)
        try:
          a = conv(b)
        except (ValueError,TypeError):
          a = b
        found[childItemID][k] = a
    return found

  def getAML(self, partKeyOrID):
    found = {}
    aml = self.getRelated(partKeyOrID, "Part", "Part AML", "Manufacturer Part")

    for item in aml:
      childItem = item['childItem']
      childItemKey = childItem.getProperty('keyed_name')
      childItemID = childItem.getID()
      found[arasID(childItemID, childItemKey)] = {}
      childItemManKey = self.inn.getItemById("Manufacturer", childItem.getProperty('manufacturer')).getProperty('keyed_name')

      found[childItemID]= (childItemKey, childItemManKey, childItem)

    return found

  def updateRelationship(self, parentKeyOrID, childKeyOrID, parentItemType, childItemType, relationshipName, **properties):
    """For a given parent and relationship within it delete relationship entries where the key or ID matches"""
    parentItem = self.getItem(parentItemType, parentKeyOrID)

    rel = parentItem.fetchRelationships(relationshipName)
    items = rel.getRelationships()

    parentItem.lockItem()
    result = None
    updated=set()

    for i in xrange(items.getItemCount()):
      item = items.getItemByIndex(i)
      childItemID =  item.getProperty("related_id")
      childItem = self.inn.getItemById(childItemType, childItemID)
      childItemKey = childItem.getProperty('keyed_name')

      update = False
      if isinstance(childKeyOrID, arasID):
        if childItemID == childKeyOrID:
          update = True
      else:
        if childItemKey == childKeyOrID:
          update = True

      if update:
        item.lockItem()
        item.setAction("update")
        result, updated = self.updateProperties(item, **properties)

        if result.isError():
          parentItem.unlockItem()
          raise pyInnovatorError, result.getErrorDetail()
        else:
          self.log.debug("Updated %s on BOM of %s" % (childItemKey, parentKeyOrID))
        #Once we've found something to update, stop
        break

    parentItem.unlockItem()
    return result, updated

  def removeRelationship(self, parentKeyOrID, childKeyOrIDToDelete, parentItemType, childItemType, relationshipName):
    """For a given parent and relationship within it delete relationship entries where the key or ID matches"""
    parentItem = self.getItem(parentItemType, parentKeyOrID)

    rel = parentItem.fetchRelationships(relationshipName)
    items = rel.getRelationships()

    parentItem.lockItem()

    for i in xrange(items.getItemCount()):
      item = items.getItemByIndex(i)
      childItemID =  item.getProperty("related_id")
      childItem = self.inn.getItemById(childItemType, childItemID)
      childItemKey = childItem.getProperty('keyed_name')

      delete = False
      if isinstance(childKeyOrIDToDelete, arasID):
        if childItemID == childKeyOrIDToDelete:
          delete = True
      else:
        if childItemKey == childKeyOrIDToDelete:
          delete = True

      if delete:
        delItem = self.inn.newItem(relationshipName, "delete")
        delItem.setID(item.getID())
        result = delItem.apply()
        if result.isError():
          parentItem.unlockItem()
          raise pyInnovatorError, result.getErrorDetail()
        else:
          self.log.debug("Deleted %s from BOM of %s" % (childItemKey, parentKeyOrID))

    parentItem.unlockItem()

  def removeBOMItem(self,parentItemKey, childItemKeyToDelete):
    self.removeRelationship(parentItemKey, childItemKeyToDelete, "Part", "Part", "Part BOM")

  def removeAMLItem(self,parentItemKey, childItemKeyToDelete):
    self.removeRelationship(parentItemKey, childItemKeyToDelete, "Part", "Manufacturer Part", "Part AML")

  def removeItems(self, itemType, keysOrIDs):
    """Remove items (keys or IDs) given in the list"""
    if isinstance(keysOrIDs,(str, arasID)):
      partList = [keysOrIDs]

    if not isinstance(keysOrIDs,(list,tuple)):
      raise ValueError

    for keyOrID in keysOrIDs:
      item = self.getItem(itemType, keyOrID)
      delItem = self.inn.newItem(itemType, "delete")
      delItem.setID(item.getID())
      result = delItem.apply()
      if result.isError():
        self.log.error("WARNING: "+result.getErrorDetail())

  def getBOMTree(self, topKeyOrID):
    self.parts = {}
    self.treeDepth = 0

    def addPart(part):
      pn = part.getProperty("keyed_name")
      parts = self.parts
      parts[pn] = {}
      parts[pn]['desc'] = part.getProperty("description")
      parts[pn]['name'] = part.getProperty("name")
      #if parts[pn]['name'] != parts[pn]['desc']:
      #  print "Name != Description in %s" % pn
      parts[pn]['ver'] = part.getProperty("creo_ver")
      parts[pn]['level'] = part.getProperty("creo_level")
      parts[pn]['class'] = part.getProperty("classification")
      parts[pn]['meta'] = {}
      parts[pn]['BOM'] = {}

    def walk(keyOrID):
      self.treeDepth += 1
      parts = self.parts
      part = self.getPart(keyOrID)
      pn = part.getProperty("keyed_name")

      #print "".join((" " * self.treeDepth, pn,))

      if pn not in parts:
        addPart(part)

        try:
          bom = self.getBOM(pn)
        except pyInnovatorError:
          print "Part %s does not exist" % pn
        else:
          for item, itemdata in bom.iteritems():
            member = item.keyed_name
            if member not in parts:
              walk(item.keyed_name)
            else:
              if parts[pn]['desc'] != part.getProperty("description"):
                print "Description updated for %s in %s"  % (member, pn)
              if parts[pn]['ver'] != part.getProperty("creo_ver"):
                print "creo_ver updated for %s in %s"  % (member, pn)
              if parts[pn]['class'] != part.getProperty("classification"):
                print "Class updated for %s in %s"  % (member, pn)

            if member not in parts[pn]['BOM']:
              parts[pn]['BOM'][member] = {}
            parts[pn]['BOM'][member]['qty'] = itemdata['quantity']
            parts[pn]['BOM'][member]['ver'] = itemdata['creo_ver']
            parts[pn]['BOM'][member]['level'] = itemdata['creo_level']
            #if parts[pn]['BOM'][member]['level'] != self.treeDepth:
            #  print "Tree depth mismatch for %s in %s" % (member, pn)
            parts[pn]['BOM'][member]['ext'] = itemdata['creo_ext']
            parts[pn]['BOM'][member]['ref'] = itemdata['reference_designator']

      self.treeDepth -= 1

    walk(topKeyOrID)

    return self.parts


  def removeParts(self,partList):
    self.removeItems("Part", partList)

  def removeManufParts(self, partList):
    self.removeItems("Manufacturer Part", partList)

  def removeManuf(self, manufList):
    self.removeItems("Manufacturer", manufList)

  def logout(self):
    self.con.Logout()
    self.con=None
  
